<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { PLACEHOLDER_FILLED_AT_EXECUTION_TIME, STICKY_NODE_TYPE } from '@/constants';

import type {
	GenericValue,
	IContextObject,
	IDataObject,
	INodeExecutionData,
	IPinData,
	IRunData,
	IRunExecutionData,
	IWorkflowDataProxyAdditionalKeys,
	Workflow,
} from 'n8n-workflow';
import { NodeConnectionType, WorkflowDataProxy } from 'n8n-workflow';

import VariableSelectorItem from '@/components/VariableSelectorItem.vue';
import type { IVariableItemSelected, IVariableSelectorOption } from '@/Interface';

import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useI18n } from '@/composables/useI18n';
import { escapeMappingString } from '@/utils/mappingUtils';

// Node types that should not be displayed in variable selector
const SKIPPED_NODE_TYPES = [STICKY_NODE_TYPE];

const props = defineProps<{
	path: string;
	redactValues: boolean;
}>();

const emit = defineEmits<{
	itemSelected: [value: IVariableItemSelected];
}>();

const router = useRouter();
const i18n = useI18n();
const workflowHelpers = useWorkflowHelpers({ router });

const workflowsStore = useWorkflowsStore();
const ndvStore = useNDVStore();

const { activeNode } = storeToRefs(ndvStore);

const variableFilter = ref('');

const extendAll = computed(() => !!variableFilter.value);

const currentResults = computed(() => getFilterResults(variableFilter.value.toLowerCase(), 0));

const workflow = computed(() => workflowHelpers.getCurrentWorkflow());

// Helper functions

/**
 * Sorts the options alphabetically. Categories get sorted before items.
 */
function sortOptions(options: IVariableSelectorOption[] | null): IVariableSelectorOption[] | null {
	if (options === null) {
		return null;
	}
	return options.sort((a: IVariableSelectorOption, b: IVariableSelectorOption) => {
		const aHasOptions = a.hasOwnProperty('options');
		const bHasOptions = b.hasOwnProperty('options');

		if (bHasOptions && !aHasOptions) {
			// When b has options but a not list it first
			return 1;
		} else if (!bHasOptions && aHasOptions) {
			// When a has options but b not list it first
			return -1;
		}

		// Else simply sort alphabetically
		if (a.name < b.name) {
			return -1;
		}
		if (a.name > b.name) {
			return 1;
		}
		return 0;
	});
}

/**
 * Removes all empty entries from the list
 */
function removeEmptyEntries(
	inputData: IVariableSelectorOption[] | IVariableSelectorOption | null,
): IVariableSelectorOption[] | IVariableSelectorOption | null {
	if (Array.isArray(inputData)) {
		const newItems: IVariableSelectorOption[] = [];
		let tempItem: IVariableSelectorOption;
		inputData.forEach((item) => {
			tempItem = removeEmptyEntries(item) as IVariableSelectorOption;
			if (tempItem !== null) {
				newItems.push(tempItem);
			}
		});
		return newItems;
	}

	if (inputData?.options) {
		const newOptions = removeEmptyEntries(inputData.options);
		if (Array.isArray(newOptions) && newOptions.length) {
			// Has still options left so return
			inputData.options = newOptions;
			return inputData;
		} else if (Array.isArray(newOptions) && newOptions.length === 0) {
			delete inputData.options;
			return inputData;
		}
		// Has no options left so remove
		return null;
	} else {
		// Is an item no category
		return inputData;
	}
}

/**
 * Normalizes the path so compare paths which have use dots or brakets
 */
function getPathNormalized(path: string | undefined): string {
	if (path === undefined) {
		return '';
	}
	const pathArray = path.split('.');

	const finalArray = [];
	let item: string;
	for (const pathPart of pathArray) {
		const pathParts = pathPart.match(/\[.*?\]/g);
		if (pathParts === null) {
			// Does not have any brakets so add as it is
			finalArray.push(pathPart);
		} else {
			// Has brakets so clean up the items and add them
			if (pathPart.charAt(0) !== '[') {
				// Does not start with a braket so there is a part before
				// we have to add
				finalArray.push(pathPart.substr(0, pathPart.indexOf('[')));
			}

			for (item of pathParts) {
				item = item.slice(1, -1);
				if (['"', "'"].includes(item.charAt(0))) {
					// Is a string
					item = item.slice(1, -1);
					finalArray.push(item);
				} else {
					// Is a number
					finalArray.push(`[${item}]`);
				}
			}
		}
	}

	return finalArray.join('|');
}

function jsonDataToFilterOption(
	inputData: IDataObject | GenericValue | IDataObject[] | GenericValue[] | null,
	parentPath: string,
	propertyName: string,
	filterText?: string,
	propertyIndex?: number,
	displayName?: string,
	skipKey?: string,
): IVariableSelectorOption[] {
	let fullpath = `${parentPath}["${propertyName}"]`;
	if (propertyIndex !== undefined) {
		fullpath += `[${propertyIndex}]`;
	}

	const returnData: IVariableSelectorOption[] = [];
	if (inputData === null) {
		returnData.push({
			name: propertyName,
			key: fullpath,
			value: '[null]',
		} as IVariableSelectorOption);
		return returnData;
	} else if (Array.isArray(inputData)) {
		let newPropertyName = propertyName;
		let newParentPath = parentPath;
		if (propertyIndex !== undefined) {
			newParentPath += `["${propertyName}"]`;
			newPropertyName = propertyIndex.toString();
		}

		const arrayData: IVariableSelectorOption[] = [];

		for (let i = 0; i < inputData.length; i++) {
			arrayData.push(
				...jsonDataToFilterOption(
					inputData[i],
					newParentPath,
					newPropertyName,
					filterText,
					i,
					`[Item: ${i}]`,
					skipKey,
				),
			);
		}

		returnData.push({
			name: displayName || propertyName,
			options: arrayData,
			key: fullpath,
			allowParentSelect: true,
			dataType: 'array',
		} as IVariableSelectorOption);
	} else if (typeof inputData === 'object') {
		const tempValue: IVariableSelectorOption[] = [];

		for (const key of Object.keys(inputData)) {
			tempValue.push(
				...jsonDataToFilterOption(
					(inputData as IDataObject)[key],
					fullpath,
					key,
					filterText,
					undefined,
					undefined,
					skipKey,
				),
			);
		}

		if (tempValue.length) {
			returnData.push({
				name: displayName || propertyName,
				options: sortOptions(tempValue),
				key: fullpath,
				allowParentSelect: true,
				dataType: 'object',
			} as IVariableSelectorOption);
		}
	} else {
		if (filterText !== undefined && propertyName.toLowerCase().indexOf(filterText) === -1) {
			// If filter is set apply it
			return returnData;
		}

		// Skip is currently only needed for leafs so only check here
		if (getPathNormalized(skipKey) !== getPathNormalized(fullpath)) {
			returnData.push({
				name: propertyName,
				key: fullpath,
				value: inputData,
			} as IVariableSelectorOption);
		}
	}

	return returnData;
}

/**
 * Get the node's output using runData
 *
 * @param {string} nodeName The name of the node to get the data of
 * @param {IRunData} runData The data of the run to get the data of
 * @param {string} filterText Filter text for parameters
 * @param {number} [itemIndex=0] The index of the item
 * @param {number} [runIndex=0] The index of the run
 * @param {string} [inputName=NodeConnectionType.Main] The name of the input
 * @param {number} [outputIndex=0] The index of the output
 * @param {boolean} [useShort=false] Use short notation $json vs. $('NodeName').json
 */
function getNodeRunDataOutput(
	nodeName: string,
	runData: IRunData,
	filterText: string,
	itemIndex = 0,
	runIndex = 0,
	inputName = NodeConnectionType.Main,
	outputIndex = 0,
	useShort = false,
): IVariableSelectorOption[] | null {
	if (!runData.hasOwnProperty(nodeName)) {
		// No data found for node
		return null;
	}

	if (runData[nodeName].length <= runIndex) {
		// No data for given runIndex
		return null;
	}

	if (
		!runData[nodeName][runIndex].hasOwnProperty('data') ||
		runData[nodeName][runIndex].data === null ||
		runData[nodeName][runIndex].data === undefined
	) {
		// Data property does not exist or is not set (even though it normally has to)
		return null;
	}

	if (!runData[nodeName][runIndex].data.hasOwnProperty(inputName)) {
		// No data found for inputName
		return null;
	}

	if (runData[nodeName][runIndex].data[inputName].length <= outputIndex) {
		// No data found for output Index
		return null;
	}

	// The data should be identical no matter to which node it gets so always select the first one
	if (
		runData[nodeName][runIndex].data[inputName][outputIndex] === null ||
		runData[nodeName][runIndex].data[inputName][outputIndex].length <= itemIndex
	) {
		// No data found for node connection found
		return null;
	}

	const outputData = runData[nodeName][runIndex].data[inputName][outputIndex][itemIndex];

	return getNodeOutput(nodeName, outputData, filterText, useShort);
}

/**
 * Get the node's output using pinData
 *
 * @param {string} nodeName The name of the node to get the data of
 * @param {IPinData[string]} pinData The node's pin data
 * @param {string} filterText Filter text for parameters
 * @param {boolean} [useShort=false] Use short notation $json vs. $('NodeName').json
 */
function getNodePinDataOutput(
	nodeName: string,
	pinData: IPinData[string],
	filterText: string,
	useShort = false,
): IVariableSelectorOption[] | null {
	const outputData = pinData.map((data) => ({ json: data }) as INodeExecutionData)[0];

	return getNodeOutput(nodeName, outputData, filterText, useShort);
}

/**
 * Returns the node's output data
 *
 * @param {string} nodeName The name of the node to get the data of
 * @param {INodeExecutionData} outputData The data of the run to get the data of
 * @param {string} filterText Filter text for parameters
 * @param {boolean} [useShort=false] Use short notation
 */
function getNodeOutput(
	nodeName: string,
	outputData: INodeExecutionData,
	filterText: string,
	useShort = false,
): IVariableSelectorOption[] | null {
	const returnData: IVariableSelectorOption[] = [];

	// Get json data
	if (outputData.hasOwnProperty('json')) {
		const jsonPropertyPrefix = useShort
			? '$json'
			: `$('${escapeMappingString(nodeName)}').item.json`;

		const jsonDataOptions: IVariableSelectorOption[] = [];
		for (const propertyName of Object.keys(outputData.json)) {
			jsonDataOptions.push(
				...jsonDataToFilterOption(
					outputData.json[propertyName],
					jsonPropertyPrefix,
					propertyName,
					filterText,
				),
			);
		}

		if (jsonDataOptions.length) {
			returnData.push({
				name: 'JSON',
				options: sortOptions(jsonDataOptions),
			});
		}
	}

	// Get binary data
	if (outputData.hasOwnProperty('binary')) {
		const binaryPropertyPrefix = useShort
			? '$binary'
			: `$('${escapeMappingString(nodeName)}').item.binary`;

		const binaryData = [];
		let binaryPropertyData: IVariableSelectorOption[] = [];

		for (const dataPropertyName of Object.keys(outputData.binary ?? {})) {
			binaryPropertyData = [];
			for (const propertyName in outputData.binary?.[dataPropertyName]) {
				if (propertyName === 'data') {
					continue;
				}

				if (filterText && propertyName.toLowerCase().indexOf(filterText) === -1) {
					// If filter is set apply it
					continue;
				}

				binaryPropertyData.push({
					name: propertyName,
					key: `${binaryPropertyPrefix}.${dataPropertyName}.${propertyName}`,
					value: outputData.binary?.[dataPropertyName][propertyName]?.toString(),
				});
			}

			if (binaryPropertyData.length) {
				binaryData.push({
					name: dataPropertyName,
					key: `${binaryPropertyPrefix}.${dataPropertyName}`,
					options: sortOptions(binaryPropertyData),
					allowParentSelect: true,
				});
			}
		}
		if (binaryData.length) {
			returnData.push({
				name: 'Binary',
				key: binaryPropertyPrefix,
				options: sortOptions(binaryData),
				allowParentSelect: true,
			});
		}
	}

	return returnData;
}

function getNodeContext(
	workflow: Workflow,
	runExecutionData: IRunExecutionData | null,
	parentNode: string[],
	nodeName: string,
	filterText: string,
): IVariableSelectorOption[] | null {
	const itemIndex = 0;
	const inputName = NodeConnectionType.Main;
	const runIndex = 0;
	const returnData: IVariableSelectorOption[] = [];

	if (activeNode.value === null) {
		return returnData;
	}

	const nodeConnection = workflow.getNodeConnectionIndexes(
		activeNode.value.name,
		parentNode[0],
		inputName,
	);
	const connectionInputData = workflowHelpers.connectionInputData(
		parentNode,
		nodeName,
		inputName,
		runIndex,
		nodeConnection,
	);

	if (connectionInputData === null) {
		return returnData;
	}

	const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
		$execution: {
			id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
			mode: 'test',
			resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
			resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
		},

		// deprecated
		$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
		$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
	};

	const dataProxy = new WorkflowDataProxy(
		workflow,
		runExecutionData,
		runIndex,
		itemIndex,
		nodeName,
		connectionInputData,
		{},
		'manual',
		additionalKeys,
	);
	const proxy = dataProxy.getDataProxy();

	const nodeContext = proxy.$node[nodeName].context as IContextObject;
	for (const key of Object.keys(nodeContext)) {
		if (filterText !== undefined && key.toLowerCase().indexOf(filterText) === -1) {
			// If filter is set apply it
			continue;
		}

		returnData.push({
			name: key,
			key: `$('${escapeMappingString(nodeName)}').context['${escapeMappingString(key)}']`,
			value: nodeContext[key],
		});
	}

	return returnData;
}

/**
 * Returns all the node parameters with values
 *
 * @param {string} nodeName The name of the node to return data of
 * @param {string} path The path to the node to pretend to key
 * @param {string} [skipParameter] Parameter to skip
 * @param {string} [filterText] Filter text for parameters
 */
function getNodeParameters(
	nodeName: string,
	path: string,
	skipParameter?: string,
	filterText?: string,
): IVariableSelectorOption[] | null {
	const node = workflow.value.getNode(nodeName);
	if (node === null) {
		return null;
	}

	const returnParameters: IVariableSelectorOption[] = [];
	for (const parameterName in node.parameters) {
		if (parameterName === skipParameter) {
			// Skip the parameter
			continue;
		}

		if (filterText !== undefined && parameterName.toLowerCase().indexOf(filterText) === -1) {
			// If filter is set apply it
			continue;
		}

		returnParameters.push(
			...jsonDataToFilterOption(
				node.parameters[parameterName],
				path,
				parameterName,
				filterText,
				undefined,
				undefined,
				skipParameter,
			),
		);
	}

	return returnParameters;
}

function getFilterResults(filterText: string, itemIndex: number): IVariableSelectorOption[] {
	const inputName = NodeConnectionType.Main;

	if (activeNode.value === null) {
		return [];
	}

	const executionData = workflowsStore.getWorkflowExecution;
	let parentNode = workflow.value.getParentNodes(activeNode.value.name, inputName, 1);
	let runData = workflowsStore.getWorkflowRunData;

	if (runData === null) {
		runData = {};
	}

	let returnData: IVariableSelectorOption[] | null = [];
	// -----------------------------------------
	// Add the parameters of the current node
	// -----------------------------------------

	// Add the parameters
	const currentNodeData: IVariableSelectorOption[] = [];

	let tempOptions: IVariableSelectorOption[];

	if (executionData?.data !== undefined) {
		const runExecutionData: IRunExecutionData = executionData.data;

		tempOptions = getNodeContext(
			workflow.value,
			runExecutionData,
			parentNode,
			activeNode.value.name,
			filterText,
		) as IVariableSelectorOption[];
		if (tempOptions.length) {
			currentNodeData.push({
				name: 'Context',
				options: sortOptions(tempOptions),
			} as IVariableSelectorOption);
		}
	}

	let tempOutputData: IVariableSelectorOption[] | null | undefined;

	if (parentNode.length) {
		// If the node has an input node add the input data

		let ndvInputNodeName = ndvStore.ndvInputNodeName;
		if (!ndvInputNodeName) {
			// If no input node is set use the first parent one
			// this is important for config-nodes which do not have
			// a main input
			ndvInputNodeName = parentNode[0];
		}

		const activeInputParentNode = parentNode.find((node) => node === ndvInputNodeName);
		if (!activeInputParentNode) {
			return [];
		}
		// Check from which output to read the data.
		// Depends on how the nodes are connected.
		// (example "IF" node. If node is connected to "true" or to "false" output)
		const nodeConnection = workflow.value.getNodeConnectionIndexes(
			activeNode.value.name,
			activeInputParentNode,
			inputName,
		);
		const outputIndex = nodeConnection === undefined ? 0 : nodeConnection.sourceIndex;

		tempOutputData = getNodeRunDataOutput(
			activeInputParentNode,
			runData,
			filterText,
			itemIndex,
			0,
			inputName,
			outputIndex,
			true,
		) as IVariableSelectorOption[];

		const pinDataOptions: IVariableSelectorOption[] = [
			{
				name: 'JSON',
				options: [],
			},
		];
		parentNode.forEach((parentNodeName) => {
			const pinData = workflowsStore.pinDataByNodeName(parentNodeName);

			if (pinData) {
				const output = getNodePinDataOutput(parentNodeName, pinData, filterText, true);

				if (pinDataOptions[0].options) {
					pinDataOptions[0].options = pinDataOptions[0].options.concat(output?.[0]?.options ?? []);
				}
			}
		});

		if ((pinDataOptions[0]?.options ?? []).length > 0) {
			if (tempOutputData) {
				const jsonTempOutputData = tempOutputData.find((tempData) => tempData.name === 'JSON');

				if (jsonTempOutputData) {
					if (!jsonTempOutputData.options) {
						jsonTempOutputData.options = [];
					}

					(pinDataOptions[0].options ?? []).forEach((pinDataOption) => {
						const existingOptionIndex = jsonTempOutputData.options!.findIndex(
							(option) => option.name === pinDataOption.name,
						);
						if (existingOptionIndex !== -1) {
							jsonTempOutputData.options![existingOptionIndex] = pinDataOption;
						} else {
							jsonTempOutputData.options!.push(pinDataOption);
						}
					});
				} else {
					tempOutputData.push(pinDataOptions[0]);
				}
			} else {
				tempOutputData = pinDataOptions;
			}
		}

		if (tempOutputData) {
			if (JSON.stringify(tempOutputData).length < 102400) {
				// Data is reasonable small (< 100kb) so add it
				currentNodeData.push({
					name: 'Input Data',
					options: sortOptions(tempOutputData),
				});
			} else {
				// Data is to large so do not add
				currentNodeData.push({
					name: 'Input Data',
					options: [
						{
							name: '[Data to large]',
						},
					],
				});
			}
		}
	}

	const initialPath = '$parameter';
	let skipParameter = props.path;
	if (skipParameter.startsWith('parameters.')) {
		skipParameter = initialPath + skipParameter.substring(10);
	}

	currentNodeData.push({
		name: i18n.baseText('variableSelector.parameters'),
		options: sortOptions(
			getNodeParameters(
				activeNode.value.name,
				initialPath,
				skipParameter,
				filterText,
			) as IVariableSelectorOption[],
		),
	});

	returnData.push({
		name: i18n.baseText('variableSelector.currentNode'),
		options: sortOptions(currentNodeData),
	});

	// Add the input data

	// -----------------------------------------
	// Add all the nodes and their data
	// -----------------------------------------
	const allNodesData: IVariableSelectorOption[] = [];
	let nodeOptions: IVariableSelectorOption[];
	const upstreamNodes = workflow.value.getParentNodes(activeNode.value.name, inputName);

	const workflowNodes = Object.entries(workflow.value.nodes);

	// Sort the nodes according to their position relative to the current node
	workflowNodes.sort((a, b) => {
		return upstreamNodes.indexOf(b[0]) - upstreamNodes.indexOf(a[0]);
	});

	for (const [nodeName, node] of workflowNodes) {
		// Add the parameters of all nodes
		// TODO: Later have to make sure that no parameters can be referenced which have expression which use input-data (for nodes which are not parent nodes)

		if (nodeName === activeNode.value.name) {
			// Skip the current node as this one get added separately
			continue;
		}
		// If node type should be skipped, continue
		if (SKIPPED_NODE_TYPES.includes(node.type)) {
			continue;
		}

		nodeOptions = [
			{
				name: i18n.baseText('variableSelector.parameters'),
				options: sortOptions(
					getNodeParameters(
						nodeName,
						`$('${escapeMappingString(nodeName)}').params`,
						undefined,
						filterText,
					),
				),
			} as IVariableSelectorOption,
		];

		if (executionData?.data !== undefined) {
			const runExecutionData: IRunExecutionData = executionData.data;

			parentNode = workflow.value.getParentNodes(nodeName, inputName, 1);
			tempOptions = getNodeContext(
				workflow.value,
				runExecutionData,
				parentNode,
				nodeName,
				filterText,
			) as IVariableSelectorOption[];
			if (tempOptions.length) {
				nodeOptions = [
					{
						name: i18n.baseText('variableSelector.context'),
						options: sortOptions(tempOptions),
					} as IVariableSelectorOption,
				];
			}
		}

		if (upstreamNodes.includes(nodeName)) {
			// If the node is an upstream node add also the output data which can be referenced
			const pinData = workflowsStore.pinDataByNodeName(nodeName);
			tempOutputData = pinData
				? getNodePinDataOutput(nodeName, pinData, filterText)
				: getNodeRunDataOutput(nodeName, runData, filterText, itemIndex);

			if (tempOutputData) {
				nodeOptions.push({
					name: i18n.baseText('variableSelector.outputData'),
					options: sortOptions(tempOutputData),
				} as IVariableSelectorOption);
			}
		}

		const shortNodeType = i18n.shortNodeType(node.type);

		allNodesData.push({
			name: i18n.headerText({
				key: `headers.${shortNodeType}.displayName`,
				fallback: nodeName,
			}),
			options: sortOptions(nodeOptions),
		});
	}

	returnData.push({
		name: i18n.baseText('variableSelector.nodes'),
		options: allNodesData,
	});

	// Remove empty entries and return
	returnData = removeEmptyEntries(returnData) as IVariableSelectorOption[] | null;

	if (returnData === null) {
		return [];
	}

	return returnData;
}

function forwardItemSelected(eventData: IVariableItemSelected) {
	emit('itemSelected', eventData);
}
</script>

<template>
	<div class="variable-selector-wrapper" @keydown.stop>
		<div class="input-wrapper">
			<n8n-input
				ref="inputField"
				v-model="variableFilter"
				:placeholder="i18n.baseText('variableSelector.variableFilter')"
				size="small"
				type="text"
			></n8n-input>
		</div>

		<div class="result-wrapper">
			<VariableSelectorItem
				v-for="option in currentResults"
				:key="option.key"
				:item="option"
				:extend-all="extendAll"
				:redact-values="redactValues"
				@item-selected="forwardItemSelected"
			></VariableSelectorItem>
		</div>
	</div>
</template>

<style scoped lang="scss">
.variable-selector-wrapper {
	border-radius: 0 0 4px 4px;
	width: 100%;
	height: 100%;
	position: relative;
}

.result-wrapper {
	line-height: 1em;
	height: 370px;
	overflow-x: hidden;
	overflow-y: auto;
	margin: 0.5em 0;
	width: 100%;
}

.result-item {
	font-size: 0.7em;
}
</style>
